<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>WS 调试面板 - Pose Actions + 视频流 + Canvas 可视化</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- Tailwind (Dev CDN) -->
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
      .scroll-area {
        overflow: auto;
      }
      .mono {
        font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
          "Liberation Mono", "Courier New", monospace;
      }
      canvas {
        background: #0b1220;
        border-radius: 0.5rem;
      }
    </style>
  </head>
  <body class="bg-gray-50 text-gray-900">
    <div class="max-w-7xl mx-auto p-4 space-y-4">
      <header class="flex items-center justify-between">
        <h1 class="text-2xl font-bold">WebSocket 调试面板 · Pose Actions</h1>
        <a
          class="text-sm text-blue-600 underline"
          href="javascript:void(0)"
          onclick="toggleHelp()"
          >使用说明</a
        >
      </header>

      <!-- 连接表单 -->
      <section class="bg-white rounded-lg shadow p-4 space-y-3">
        <div class="flex flex-col md:flex-row gap-3">
          <div class="flex-1">
            <label class="block text-sm font-medium text-gray-700 mb-1"
              >WebSocket 地址</label
            >
            <input
              id="wsUrl"
              type="text"
              class="w-full rounded border-gray-300 focus:border-blue-500 focus:ring-blue-500"
              value="ws://127.0.0.1:8765"
            />
            <p class="text-xs text-gray-500 mt-1">
              默认：ws://127.0.0.1:8765（与服务器配置一致）
            </p>
          </div>
          <div class="flex items-end gap-2">
            <button
              id="btnConnect"
              class="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700"
            >
              连接
            </button>
            <button
              id="btnDisconnect"
              class="px-4 py-2 rounded bg-gray-200 text-gray-800 hover:bg-gray-300"
              disabled
            >
              断开
            </button>
          </div>
        </div>

        <div class="text-sm">
          状态：
          <span id="connStatus" class="font-semibold text-gray-700"
            >未连接</span
          >
        </div>
      </section>

      <!-- 新增：视频流预览与控制 -->
      <section class="bg-white rounded-lg shadow p-4 space-y-3">
        <div class="flex items-center justify-between">
          <h2 class="text-lg font-semibold">视频流（RGBA8UNORM）</h2>
          <div class="flex items-center gap-3">
            <button
              id="btnStartVideo"
              class="px-3 py-1.5 rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
              disabled
              onclick="startVideo()"
            >
              开始视频
            </button>
            <button
              id="btnStopVideo"
              class="px-3 py-1.5 rounded bg-gray-200 text-gray-800 hover:bg-gray-300 disabled:opacity-50"
              disabled
              onclick="stopVideo()"
            >
              停止视频
            </button>
          </div>
        </div>
        <canvas id="videoCanvas" width="640" height="360"></canvas>
        <p class="text-xs text-gray-500">
          说明：服务端按 9 字节帧头 + RGBA8UNORM
          像素发送；首帧会自动调整画布尺寸。
        </p>
      </section>

      <!-- Canvas 可视化 -->
      <section class="bg-white rounded-lg shadow p-4 space-y-3">
        <div class="flex items-center justify-between">
          <h2 class="text-lg font-semibold">骨骼可视化（9 关键点）</h2>
          <div class="flex items-center gap-3 text-sm">
            <label class="inline-flex items-center gap-2">
              画布大小
              <input
                id="canvasW"
                type="number"
                value="960"
                class="w-20 rounded border-gray-300"
              />
              ×
              <input
                id="canvasH"
                type="number"
                value="540"
                class="w-20 rounded border-gray-300"
              />
              <button
                class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
                onclick="resizeCanvas()"
              >
                应用
              </button>
            </label>
            <label class="inline-flex items-center gap-2">
              轮询间隔(ms)
              <input
                id="pollMs"
                type="number"
                value="200"
                class="w-24 rounded border-gray-300"
              />
            </label>
            <button
              class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
              onclick="clearPersons()"
            >
              清空人员缓存
            </button>
          </div>
        </div>
        <canvas id="poseCanvas" width="960" height="540"></canvas>
        <p class="text-xs text-gray-500">
          提示：画布使用归一化坐标，左上角(0,0)，右下角(1,1)。
        </p>
      </section>

      <!-- 最近动作速览 -->
      <section class="bg-white rounded-lg shadow p-4 space-y-3">
        <h2 class="text-lg font-semibold">最近动作（Top-K 广播）</h2>
        <div
          id="latestActions"
          class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"
        ></div>
      </section>

      <!-- 快捷命令 -->
      <section class="bg-white rounded-lg shadow p-4 space-y-3">
        <h2 class="text-lg font-semibold">快捷命令</h2>
        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
          <div class="p-3 border rounded space-y-2">
            <div class="font-medium">查询单个关节</div>
            <div class="flex gap-2">
              <input
                id="personId1"
                type="number"
                class="w-24 rounded border-gray-300"
                value="0"
              />
              <input
                id="jointName"
                type="text"
                class="flex-1 rounded border-gray-300"
                value="LEFT_WRIST"
              />
            </div>
            <button
              class="px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700"
              onclick="sendGetJoint()"
            >
              发送 get_joint
            </button>
          </div>

          <div class="p-3 border rounded space-y-2">
            <div class="font-medium">查询多个关节</div>
            <div class="flex gap-2">
              <input
                id="personId2"
                type="number"
                class="w-24 rounded border-gray-300"
                value="0"
              />
              <input
                id="jointList"
                type="text"
                class="flex-1 rounded border-gray-300"
                value="LEFT_WRIST,RIGHT_WRIST,LEFT_SHOULDER"
              />
            </div>
            <button
              class="px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700"
              onclick="sendGetJoints()"
            >
              发送 get_joints
            </button>
          </div>

          <div class="p-3 border rounded space-y-2">
            <div class="font-medium">清空日志</div>
            <p class="text-sm text-gray-500">清除下方“接收/发送”面板内容。</p>
            <button
              class="px-3 py-1.5 rounded bg-gray-700 text-white hover:bg-black"
              onclick="clearLogs()"
            >
              清空
            </button>
          </div>
        </div>
      </section>

      <!-- 发送自定义消息 -->
      <section class="bg-white rounded-lg shadow p-4 space-y-3">
        <h2 class="text-lg font-semibold">发送自定义消息</h2>
        <div class="flex gap-3">
          <textarea
            id="outgoing"
            class="w-full h-28 rounded border-gray-300 mono"
            placeholder='例如：{"type":"get_joint","person_id":0,"joint":"LEFT_WRIST"}'
          ></textarea>
          <div class="flex flex-col gap-2">
            <button
              class="px-4 py-2 rounded bg-green-600 text白 hover:bg-green-700"
              onclick="sendRaw()"
            >
              发送
            </button>
            <button
              class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300"
              onclick="formatJSON()"
            >
              格式化
            </button>
          </div>
        </div>
      </section>

      <!-- 消息日志 -->
      <section class="grid grid-cols-1 lg:grid-cols-2 gap-4">
        <div class="bg-white rounded-lg shadow p-4 space-y-2">
          <h2 class="text-lg font-semibold">接收</h2>
          <div
            id="incoming"
            class="scroll-area h-72 bg-gray-900 text-gray-100 p-3 rounded mono text-sm"
          ></div>
        </div>
        <div class="bg白 rounded-lg shadow p-4 space-y-2">
          <h2 class="text-lg font-semibold">发送</h2>
          <div
            id="sent"
            class="scroll-area h-72 bg-gray-900 text-gray-100 p-3 rounded mono text-sm"
          ></div>
        </div>
      </section>

      <!-- 使用说明 -->
      <section
        id="help"
        class="bg-white rounded-lg shadow p-4 space-y-2 hidden"
      >
        <h2 class="text-lg font-semibold">使用说明</h2>
        <ul class="list-disc ml-5 text-sm space-y-1">
          <li>
            点击“连接”后，页面每 200ms 轮询一次每个已知人员的 9
            个关节点，用于绘制骨架。
          </li>
          <li>
            服务器应支持消息
            <code>{"type":"get_joints","person_id":0,"joints":[...]}</code>。
          </li>
          <li>
            服务端返回格式为
            <code
              >{"type":"joints","person_id":0,"data":{NAME:[x,y,z,visibility],...}}</code
            >，本页已适配。
          </li>
          <li>
            视频流：点击“开始视频”向服务端发送
            <code>{"type":"start_video"}</code>，服务端以二进制（9 字节帧头 +
            RGBA8 像素）推送； 点击“停止视频”发送
            <code>{"type":"stop_video"}</code>。
          </li>
          <li>
            若跨域或网络受限导致连接失败，请检查浏览器网络策略与服务器监听地址。
          </li>
        </ul>
      </section>

      <footer class="text-center text-xs text-gray-500 py-6">
        © WS Debugger for Pose Actions
      </footer>
    </div>

    <script>
      // ===== 配置与关键点映射 =====
      const NINE_JOINTS = [
        "NOSE",
        "LEFT_SHOULDER",
        "RIGHT_SHOULDER",
        "LEFT_WRIST",
        "RIGHT_WRIST",
        "LEFT_HIP",
        "RIGHT_HIP",
        "LEFT_ANKLE",
        "RIGHT_ANKLE",
      ];

      const BONES = [
        ["LEFT_SHOULDER", "RIGHT_SHOULDER"],
        ["LEFT_SHOULDER", "LEFT_HIP"],
        ["RIGHT_SHOULDER", "RIGHT_HIP"],
        ["LEFT_SHOULDER", "LEFT_WRIST"],
        ["RIGHT_SHOULDER", "RIGHT_WRIST"],
        ["LEFT_HIP", "RIGHT_HIP"],
        ["LEFT_HIP", "LEFT_ANKLE"],
        ["RIGHT_HIP", "RIGHT_ANKLE"],
        ["NOSE", "LEFT_SHOULDER"],
        ["NOSE", "RIGHT_SHOULDER"],
      ];

      // ===== 全局状态 =====
      let ws = null;
      const latestByPerson = new Map(); // person_id -> { ts, actions: [...] }
      const personJoints = new Map(); // person_id -> { jointName: {x,y,visibility} }
      let pollTimer = null;

      // 视频流状态
      const VIDEO_MAGIC = "RB";
      const VIDEO_VERSION = 1;
      const VIDEO_FMT_RGBA8 = 0;
      const VIDEO_FMT_RGB888 = 1;
      const VIDEO_HEADER_LEN = 9;
      let videoOn = false;

      // ===== DOM helpers =====
      const ci = (id) => document.getElementById(id);

      function setStatus(text, color = "text-gray-700") {
        const el = ci("connStatus");
        el.textContent = text;
        el.className = `font-semibold ${color}`;
      }

      function logIncoming(obj) {
        if (typeof obj === "string") {
          obj = JSON.parse(obj);
        }
        if (obj.type === "actions") {
          return;
        }
        const el = ci("incoming");
        el.textContent +=
          (typeof obj === "string" ? obj : JSON.stringify(obj, null, 2)) + "\n";
        el.scrollTop = el.scrollHeight;
      }

      function logSent(obj) {
        const el = ci("sent");
        el.textContent +=
          (typeof obj === "string" ? obj : JSON.stringify(obj, null, 2)) + "\n";
        el.scrollTop = el.scrollHeight;
      }

      function clearLogs() {
        ci("incoming").textContent = "";
        ci("sent").textContent = "";
      }

      function toggleHelp() {
        ci("help").classList.toggle("hidden");
      }

      // ===== WebSocket 连接管理 =====
      function connectWS() {
        const url = ci("wsUrl").value.trim();
        if (ws && ws.readyState === WebSocket.OPEN) {
          alert("已连接");
          return;
        }
        try {
          ws = new WebSocket(url);
          ws.binaryType = "arraybuffer"; // 关键：接受二进制视频帧
        } catch (e) {
          setStatus("URL 无效", "text-red-600");
          return;
        }

        setStatus("连接中...", "text-yellow-600");
        ci("btnConnect").disabled = true;
        ci("btnDisconnect").disabled = false;

        ws.onopen = () => {
          setStatus("已连接", "text-green-600");
          ci("btnStartVideo").disabled = false;
          ci("btnStopVideo").disabled = true;
          installOnMessageHandler();
          startPolling();
        };
        ws.onclose = () => {
          setStatus("已断开", "text-gray-600");
          ci("btnConnect").disabled = false;
          ci("btnDisconnect").disabled = true;
          ci("btnStartVideo").disabled = true;
          ci("btnStopVideo").disabled = true;
          videoOn = false;
          stopPolling();
        };
        ws.onerror = (e) => {
          setStatus("错误（查看控制台）", "text-red-600");
          console.error(e);
        };
      }

      function disconnectWS() {
        if (ws) {
          try {
            if (videoOn) stopVideo(); // 尽量告知服务端
          } catch (e) {}
          ws.close();
          ws = null;
        }
      }

      // ===== 视频控制 =====
      function startVideo() {
        if (!ws || ws.readyState !== WebSocket.OPEN) return;
        const msg = { type: "start_video" };
        ws.send(JSON.stringify(msg));
        logSent(msg);
        videoOn = true;
        ci("btnStartVideo").disabled = true;
        ci("btnStopVideo").disabled = false;
      }

      function stopVideo() {
        if (!ws || ws.readyState !== WebSocket.OPEN) return;
        const msg = { type: "stop_video" };
        ws.send(JSON.stringify(msg));
        logSent(msg);
        videoOn = false;
        ci("btnStartVideo").disabled = false;
        ci("btnStopVideo").disabled = true;
      }

      // ===== 轮询请求 =====
      function ensurePerson(pid) {
        if (!personJoints.has(pid)) personJoints.set(pid, {});
      }

      function clearPersons() {
        personJoints.clear();
        latestByPerson.clear();
        renderLatestActions();
        drawCanvas();
      }

      function startPolling() {
        stopPolling();
        const gap = Math.max(50, parseInt(ci("pollMs").value || "200", 10));
        pollTimer = setInterval(() => pollAllPersons(true), gap); // 全量查询
      }

      function stopPolling() {
        if (pollTimer) {
          clearInterval(pollTimer);
          pollTimer = null;
        }
      }

      function pollAllPersons(preferAll = true) {
        if (!ws || ws.readyState !== WebSocket.OPEN) return;

        if (preferAll) {
          const msg = {
            type: "get_joints",
            person_id: -1, // 关键：全量
            joints: NINE_JOINTS,
          };
          ws.send(JSON.stringify(msg));
          logSent(msg);
          return;
        }

        // 兼容：逐 pid 查询（可保留）
        const pids = Array.from(
          new Set([...latestByPerson.keys(), ...personJoints.keys()])
        ).sort((a, b) => a - b);
        for (const pid of pids) {
          const msg = {
            type: "get_joints",
            person_id: pid,
            joints: NINE_JOINTS,
          };
          ws.send(JSON.stringify(msg));
          logSent(msg);
        }
      }

      // ===== 发送命令 =====
      function sendGetJoint() {
        const pid = parseInt(ci("personId1").value || "0", 10);
        const joint = ci("jointName").value.trim();
        const msg = { type: "get_joint", person_id: pid, joint };
        sendJSON(msg);
      }

      function sendGetJoints() {
        const pid = parseInt(ci("personId2").value || "0", 10);
        const joints = ci("jointList")
          .value.split(",")
          .map((s) => s.trim())
          .filter(Boolean);
        const msg = { type: "get_joints", person_id: pid, joints };
        sendJSON(msg);
      }

      function sendRaw() {
        const text = ci("outgoing").value.trim();
        if (!ws || ws.readyState !== WebSocket.OPEN) {
          alert("未连接");
          return;
        }
        try {
          JSON.parse(text);
        } catch (e) {
          if (!confirm("该文本不是合法 JSON，仍要发送吗？")) return;
        }
        ws.send(text);
        logSent(text);
      }

      function formatJSON() {
        const text = ci("outgoing").value.trim();
        if (!text) return;
        try {
          const obj = JSON.parse(text);
          ci("outgoing").value = JSON.stringify(obj, null, 2);
        } catch (e) {
          alert("不是合法 JSON");
        }
      }

      function sendJSON(obj) {
        if (!ws || ws.readyState !== WebSocket.OPEN) {
          alert("未连接");
          return;
        }
        const text = JSON.stringify(obj);
        ws.send(text);
        logSent(text);
      }

      // ===== onmessage 处理（JSON + 视频）=====
      function installOnMessageHandler() {
        ws.onmessage = (evt) => {
          const data = evt.data;

          // 二进制视频帧（ArrayBuffer）
          if (data instanceof ArrayBuffer) {
            handleVideoFrame(data);
            return;
          }

          // JSON 文本
          if (typeof data === "string") {
            logIncoming(data);
            let obj = null;
            try {
              obj = JSON.parse(data);
            } catch (_) {
              return;
            }

            // acks（可选）
            if (obj.type === "ack" && obj.op === "start_video") {
              // 可在此设置 videoOn=true（已在发送时设置）
            }
            if (obj.type === "ack" && obj.op === "stop_video") {
              // 可在此设置 videoOn=false（已在发送时设置）
            }

            // 动作广播
            if (obj.type === "actions" && typeof obj.person_id === "number") {
              latestByPerson.set(obj.person_id, {
                ts: obj.ts || Date.now() / 1000,
                actions: obj.actions || [],
              });
              renderLatestActions();
              ensurePerson(obj.person_id);
            }

            // joints 响应（多人/单人）
            if (
              obj.type === "joints" &&
              typeof obj.person_id === "number" &&
              obj.data
            ) {
              // 多人：person_id < 0
              if (obj.person_id < 0 && typeof obj.data === "object") {
                personJoints.clear();
                for (const [pidStr, jointDict] of Object.entries(obj.data)) {
                  const pid = parseInt(pidStr, 10);
                  if (isNaN(pid)) continue;
                  const store = {};
                  for (const [name, arr] of Object.entries(jointDict || {})) {
                    if (!Array.isArray(arr) || arr.length < 2) continue;
                    const x = arr[0],
                      y = arr[1];
                    const vis =
                      arr.length >= 4 && typeof arr[3] === "number"
                        ? arr[3]
                        : 1.0;
                    store[name] = { x, y, visibility: vis };
                  }
                  personJoints.set(pid, store);
                }
                drawCanvas();
                return;
              }

              // 单人
              if (obj.person_id >= 0) {
                const store = personJoints.get(obj.person_id) || {};
                for (const [name, arr] of Object.entries(obj.data)) {
                  if (!Array.isArray(arr) || arr.length < 2) continue;
                  const x = arr[0],
                    y = arr[1];
                  const vis =
                    arr.length >= 4 && typeof arr[3] === "number"
                      ? arr[3]
                      : 1.0;
                  store[name] = { x, y, visibility: vis };
                }
                personJoints.set(obj.person_id, store);
                drawCanvas();
              }
            }

            // 兼容：单个 joint
            if (
              obj.type === "joint" &&
              typeof obj.person_id === "number" &&
              obj.joint &&
              Array.isArray(obj.data)
            ) {
              ensurePerson(obj.person_id);
              const arr = obj.data;
              personJoints.get(obj.person_id)[obj.joint] = {
                x: arr[0],
                y: arr[1],
                visibility: arr.length >= 4 ? arr[3] : 1.0,
              };
              drawCanvas();
            }

            return;
          }

          // 其他类型（Blob 等）忽略或按需处理
        };
      }

      // 解析并绘制 RGBA8UNORM 视频帧
      function handleVideoFrame(buffer) {
        const view = new DataView(buffer);
        if (view.byteLength < VIDEO_HEADER_LEN) return;

        // 读取帧头
        const magic =
          String.fromCharCode(view.getUint8(0)) +
          String.fromCharCode(view.getUint8(1));
        const ver = view.getUint8(2);
        const width = view.getUint16(3, true);
        const height = view.getUint16(5, true);
        const ch = view.getUint8(7);
        const fmt = view.getUint8(8);

        // 基础校验
        if (magic !== VIDEO_MAGIC || ver !== VIDEO_VERSION) {
          return;
        }

        const canvas = document.getElementById("videoCanvas");
        const ctx = canvas.getContext("2d", { willReadFrequently: true });

        // 首帧/分辨率变化时调整画布
        if (canvas.width !== width || canvas.height !== height) {
          canvas.width = width;
          canvas.height = height;
        }

        const pixelBytes = buffer.slice(VIDEO_HEADER_LEN);

        if (fmt === VIDEO_FMT_RGBA8 && ch === 4) {
          // 直接作为 RGBA8
          const pixels = new Uint8ClampedArray(pixelBytes);
          const img = new ImageData(pixels, width, height);
          ctx.putImageData(img, 0, 0);
          return;
        }

        if (fmt === VIDEO_FMT_RGB888 && ch === 3) {
          // 将 RGB888 转为 RGBA8（补 alpha=255）
          const rgb = new Uint8Array(pixelBytes); // 长度 = width * height * 3
          const rgba = new Uint8ClampedArray(width * height * 4);

          let si = 0,
            di = 0;
          const n = width * height;
          for (let i = 0; i < n; i++) {
            rgba[di] = rgb[si]; // R
            rgba[di + 1] = rgb[si + 1]; // G
            rgba[di + 2] = rgb[si + 2]; // B
            rgba[di + 3] = 255; // A
            si += 3;
            di += 4;
          }
          const img = new ImageData(rgba, width, height);
          ctx.putImageData(img, 0, 0);
          return;
        }

        // 未知格式：忽略或在日志中提示
        // console.warn("Unsupported video frame format:", { ch, fmt });
      }
      // ===== 动作速览渲染 =====
      function renderLatestActions() {
        const container = ci("latestActions");
        container.innerHTML = "";
        const entries = Array.from(latestByPerson.entries()).sort(
          (a, b) => a[0] - b[0]
        );
        for (const [pid, data] of entries) {
          const card = document.createElement("div");
          card.className = "border rounded p-3 bg-white";
          const ts = new Date(
            (data.ts || Date.now()) * 1000
          ).toLocaleTimeString();
          const actions = (data.actions || [])
            .map((a) => {
              const score = a.score != null ? a.score.toFixed(2) : "-";
              const barW = Math.max(
                2,
                Math.min(100, Math.round((a.score || 0) * 100))
              );
              return `
            <div class="mb-2">
              <div class="flex justify-between text-sm">
                <span class="font-medium">${a.label}</span>
                <span>${score}</span>
              </div>
              <div class="h-2 bg-gray-200 rounded">
                <div class="h-2 bg-blue-600 rounded" style="width:${barW}%"></div>
              </div>
            </div>
          `;
            })
            .join("");

          card.innerHTML = `
          <div class="flex items-center justify之间 mb-2">
            <div class="text-sm font-semibold">Person #${pid}</div>
            <div class="text-xs text-gray-500">${ts}</div>
          </div>
          ${actions || '<div class="text-xs text-gray-500">暂无动作</div>'}
        `;
          container.appendChild(card);
        }
      }

      // ===== Canvas 绘制（关键点）=====
      function resizeCanvas() {
        const c = ci("poseCanvas");
        c.width = Math.max(200, parseInt(ci("canvasW").value || "960", 10));
        c.height = Math.max(150, parseInt(ci("canvasH").value || "540", 10));
        drawCanvas();
      }

      function drawCanvas() {
        const canvas = ci("poseCanvas");
        const ctx = canvas.getContext("2d");
        const W = canvas.width,
          H = canvas.height;

        // 背景 + 网格
        ctx.clearRect(0, 0, W, H);
        ctx.strokeStyle = "rgba(255,255,255,0.06)";
        ctx.lineWidth = 1;
        for (let i = 1; i < 10; i++) {
          const x = (W / 10) * i,
            y = (H / 10) * i;
          ctx.beginPath();
          ctx.moveTo(x, 0);
          ctx.lineTo(x, H);
          ctx.stroke();
          ctx.beginPath();
          ctx.moveTo(0, y);
          ctx.lineTo(W, y);
          ctx.stroke();
        }

        // 按 person 绘制
        const pids = Array.from(personJoints.keys()).sort((a, b) => a - b);
        pids.forEach((pid, idx) => {
          const joints = personJoints.get(pid) || {};
          const color = palette(idx);

          // 收集 9 点
          const points = {};
          NINE_JOINTS.forEach((name) => {
            const p = joints[name];
            if (p && typeof p.x === "number" && typeof p.y === "number") {
              points[name] = {
                x: p.x,
                y: p.y,
                visibility: p.visibility ?? 1.0,
              };
            }
          });

          // 画骨架
          ctx.lineWidth = 3;
          BONES.forEach(([a, b]) => {
            const pa = points[a],
              pb = points[b];
            if (!pa || !pb) return;
            const va = pa.visibility ?? 0,
              vb = pb.visibility ?? 0;
            const alpha = Math.min(1, Math.max(0.2, (va + vb) * 0.5));
            ctx.strokeStyle = rgba(color, 0.7 * alpha);
            ctx.beginPath();
            ctx.moveTo(pa.x * W, pa.y * H);
            ctx.lineTo(pb.x * W, pb.y * H);
            ctx.stroke();
          });

          // 画关键点
          for (const [name, p] of Object.entries(points)) {
            const alpha = Math.min(1, Math.max(0.3, p.visibility ?? 1.0));
            ctx.fillStyle = rgba(color, 0.9 * alpha);
            ctx.beginPath();
            ctx.arc(p.x * W, p.y * H, 5, 0, Math.PI * 2);
            ctx.fill();
          }

          // 标签
          ctx.fillStyle = rgba(color, 1);
          ctx.font = "14px ui-sans-serif, system-ui, -apple-system";
          const labelPos =
            points.NOSE || points.LEFT_SHOULDER || points.RIGHT_SHOULDER;
          if (labelPos) {
            drawTag(
              ctx,
              `P#${pid}`,
              labelPos.x * W,
              Math.max(12, labelPos.y * H - 12),
              color
            );
          }
        });
      }

      // ===== 工具函数 =====
      function drawTag(ctx, text, x, y, color) {
        ctx.font = "bold 13px ui-sans-serif, system-ui";
        ctx.textBaseline = "top";
        const padX = 6,
          padY = 3;
        const metrics = ctx.measureText(text);
        const w = metrics.width + padX * 2;
        const h = 18;
        ctx.fillStyle = rgba([20, 24, 38], 0.75);
        ctx.fillRect(x - w / 2, y - h, w, h);
        ctx.strokeStyle = rgba(color, 0.85);
        ctx.strokeRect(x - w / 2, y - h, w, h);
        ctx.fillStyle = rgba(color, 0.95);
        ctx.fillText(text, x - w / 2 + padX, y - h + padY);
      }

      function rgba(rgbArr, a) {
        const [r, g, b] = rgbArr;
        return `rgba(${r}, ${g}, ${b}, ${a})`;
      }

      function palette(i) {
        const colors = [
          [59, 130, 246], // blue-500
          [16, 185, 129], // emerald-500
          [249, 115, 22], // orange-500
          [236, 72, 153], // pink-500
          [139, 92, 246], // violet-500
          [234, 179, 8], // yellow-500
          [34, 197, 94], // green-500
          [239, 68, 68], // red-500
        ];
        return colors[i % colors.length];
      }

      // 初始渲染与事件绑定
      resizeCanvas();
      document
        .getElementById("btnConnect")
        .addEventListener("click", connectWS);
      document
        .getElementById("btnDisconnect")
        .addEventListener("click", disconnectWS);
      window.addEventListener("beforeunload", () => {
        try {
          if (ws) {
            if (videoOn) stopVideo();
            ws.close();
          }
        } catch (e) {}
      });
    </script>
  </body>
</html>
